Skip to content

feat(session-continuity): survive single-dialog history compaction — root-anchor fallback + tail-anchored overlap; acct= in reuse log (opt-in, DEVIN_CONNECT_SESSION_REUSE) - #248

Merged
dwgx merged 4 commits into
dwgx:masterfrom
warelik:pr/session-compaction-survival
Aug 9, 2026

Conversation

@warelik

@warelik warelik commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

中文 TL;DR

部分客户端会 compact 自己的历史,会话复用时最近的配对窗口(pair window)变空或过短,会话认不出来、每轮都被当新会话,continuity 和 stable ModelConfig 在长程 agent 作业里悄悄失效。本 PR 给会话解析加 root-anchor fallback:窗口不可靠时改用对话根锚点匹配,并在出现歧义(两个活会话同根)时拒绝误认;overlap 计分改为尾锚点加权(紧凑化切的是头不是尾)。另在 reuse 日志补 acct= 字段,按账号排障不用再猜。仍在 DEVIN_CONNECT_SESSION_REUSE 门控内,关闭时行为逐字不变。

覆盖范围(评审后收窄):单对话压缩存活。 多个对话共享同一 opener 时,压缩后的那一路按歧义规则退化为新 session —— 这是有意的保守选择:宁可退化,也不能猜错把 B 接到 A 上(测试已钉:two live states sharing the root anchor with no pair evidence are ambiguous → a NEW id forms (no hijack))。fork 场景的可判定化(比如用 dialogAnchor 区分同根分叉)留作后续,不在本 PR。

Что меняется

  • src/session-continuity.js:
    • при пустом/коротком pairWindow — fallback на dialog root anchor;
    • два живых состояния на одном корне → отказ (не угадываем, а отдаём новый session);
    • overlap score с опорой на tail-якорь (компакция отрезает голову цепи, хвост сохраняется);
    • форк-кандидаты тоже индексируются по rootKey — hijack через форк не проходит;
    • просроченные кандидаты в fallback-пути вычищаются по TTL, мёртвая сессия не воскресает.
  • src/handlers/chat.js: строка reuse-лога обогащена acct=.

Зачем

Клиентская компакция истории — нормальное поведение агентов на длинных прогонах. Без fallback сессия после компакии не резолвится, и именно тогда, когда continuity нужнее всего, он молча выключается.

Предел (важно)

Fallback работает, когда ровно ОДИН живой диалог делит корневой якорь. Если два диалога начались с одного opener (типично для агент-клиентов) и один из них форкнулся и сжался — сработает правило неоднозначности: форк получит НОВЫЙ session. Это осознанная деградация, не дыра: угадать чужую сессию хуже, чем потерять continuity. Тесты этот предел пинуют; тело PR теперь тоже.

Test plan

  • Тесты в test/session-continuity.test.js (+ test/chat-reuse-log.test.js): компакция клиентом истории (пустое/короткое окно пар) → резолв по root anchor; tail-anchored overlap различает свою/чужую сессию; два живых состояния на одном корне → отказ обоим; TTL-просрочка в fallback → новый session; acct= в reuse-логе.
  • Мутационная spec test/mutations/session-continuity-compaction-survival.json (3 мутации, все CAUGHT) + все spec репо: EXIT=0, anchor'ы ровно по разу (перепроверены после rebase на master 51846e0).
  • Полный сьют: 3698 pass / 0 fail (rebase на master 51846e0).

Порядок мержа (координация с #242)

Предлагаемый порядок пачки: #242#243#247#248#249. Этот PR идёт после #242 по session-continuity.js: root-фолбэк при попадании реиндексирует pairWindow под pair-хэши сжатой истории, а getSessionModelConfig/getSessionReasoningTrail из #242 читают состояние после resolveSessionId. После мержа #242 этот PR делает: rebase, повторный прогон обеих spec (session-continuity-compaction-survival + reasoning-continuity), и кросс-проверку «компакция → getter'ы #242 видят то же состояние».

Безопасность

Гейт DEVIN_CONNECT_SESSION_REUSE default OFF: при выключенном гейте код пути не исполняется, поведение побайтово неизменно. Fallback срабатывает только при отсутствии свежих парных свидетельств и только на единственного живого кандидата.

@dwgx

dwgx commented Aug 7, 2026

Copy link
Copy Markdown
Owner

评审:机制是对的,三道闸我逐条驱动过。请补一条 —— PR 正文承诺的覆盖范围比实际大一档。

我实跑过什么

head 9704634,worktree 隔离,未污染 master:

结果
npm run test:release 3610 pass / 0 fail(275 个文件,731 suites) —— 与你声明的逐字一致
session-continuity-compaction-survival.json 3/3 CAUGHT,含 over-reach 对照
全仓 spec anchor 153 条,零个不唯一
dependencies 仍为空
门关(DEVIN_CONNECT_SESSION_REUSE 未设) resolveSessionId 返回空 —— inert 成立

三道闸我是分别驱动的,不是读代码看出来的。 seen.size === 0 那道尤其关键:它让「前缀 pair 仍命中索引的分叉对话」走不到 root 回退,而你把理由写在注释里(pair evidence exists; it just scores 0)。overlapScore 改成锚在候选窗口的尾部也对 —— 压缩砍头不砍尾,所以保留的尾巴必然是已提交链的后缀,前缀-only 的 run 得 0 分。这两处是这个 PR 里最容易写错而你没写错的地方。

commitAfterResponse 给 fork 也建 root 索引那一段,注释说明了它存在的理由是让歧义规则能看见所有共享 root 的 state。这条是防劫持的支点,我特意攻了它。


M1(请补)— fork 场景下这个特性不生效,而正文没说

我构造了这个仓库最常见的形态:两个对话同一个 opener(agent 客户端普遍如此),各自分叉,然后其中一个压缩。

A: t1=614d3b6f  t2=614d3b6f  t3=614d3b6f
B: t1=614d3b6f  t2=0eb7492f  compacted=070be495

B 压缩后拿到 A 的 session : false      ← 没有劫持,歧义规则起作用了
A 保住自己的 session      : true
B 保住自己的 session      : false      ← 但 B 拿到的是全新 id

没有安全问题 —— 我原本怀疑的跨对话泄漏不成立,live.length === 1 那道闸拦住了。你的测试 :420「two live states sharing the root anchor with no pair evidence are ambiguous → a NEW id forms (no hijack)」和我实测一致。

但结果是:只要有第二个对话共享同一个 opener,压缩存活就失效,B 掉到新 session,也就是这个 PR 要修的那个连续性丢失。而 PR 正文写的是:

survive client history compaction

这句话在单对话下成立,在 fork 下不成立。你的测试已经钉住了这个边界,正文没有。 两条都不难,但我倾向后者:

  1. 收窄标题/正文,明确说「单对话压缩存活;多个对话共享 opener 时按歧义规则退化为新 session」。
  2. 让歧义可判定 —— 比如在 root 之外再带一个分叉判据(dialogAnchor 已经在 state 里了),使 B 能认回自己而不是退化。

我倾向 1。方案 2 是在给一个「不可判定的输入」造判据,而歧义规则本身是正确的保守选择 —— 宁可退化成新 session,也不能猜错把 B 接到 A 上。这和你在 #242 评审里接受的那个取舍是同一个方向。

M2(请核)— 与 #242session-continuity.js 上语义冲突

你和 #242 都改这个文件,两个对 master 都是 CLEAN,但那是各自对 base 算的,不说明两两之间。我这轮读出来的具体冲突点:

本 PR 的 root 回退在命中时会 state.pairWindow = newWindow 然后 indexState(...) —— 把 state 重新索引到压缩后历史的 pair hash 下。而 #242getSessionModelConfig / getSessionReasoningTrailresolveSessionId 之后调用,于是它看到的「pair 证据」已经是被本 PR 重写过的那一份。两个 PR 合并后互相改变对方的前提。

请你定一个合并顺序,并说明后合的那个要重做什么。我不打算替你选 —— 你比我清楚 #242 的 T1/T2 依赖哪一份 pairWindow。

W ARELIK and others added 4 commits August 8, 2026 10:13
… + tail-anchored overlap; acct= in reuse log

- overlapScore: document tail-anchored semantics (body already tail-anchored on
  master: contiguous run ending at the candidate's LAST hash).
- resolveSessionId: root-anchor fallback re-associates the committed session
  when a compacted history's pairs were rewritten (0/N survive byte-for-byte)
  but the first input turn survives verbatim. Fires only when NO incoming hash
  matched any stored index (pair evidence gone) — a divergent dialog whose
  prefix pair still hits the index must not be re-associated.
- commitAfterResponse: root-index forked states so the fallback's ambiguity rule
  (several live root candidates -> assign to none) can see same-opener forks and
  never hijack a fork into the original session.
- chat.js: DEVIN_CONNECT reuse log now carries acct=<account id> (env-token
  fallback).
- tests: compaction survival (root re-association + ambiguous no-hijack),
  tail-anchored overlap (prefix-only run scores 0; suffix match resolves),
  chat reuse-log acct= construction.
…ndex the new pair window; trailing newline
@warelik
warelik force-pushed the pr/session-compaction-survival branch from 9704634 to d4d416f Compare August 8, 2026 08:01
@warelik warelik changed the title feat(session-continuity): survive client history compaction — root-anchor fallback + tail-anchored overlap; acct= in reuse log (opt-in, DEVIN_CONNECT_SESSION_REUSE) feat(session-continuity): survive single-dialog history compaction — root-anchor fallback + tail-anchored overlap; acct= in reuse log (opt-in, DEVIN_CONNECT_SESSION_REUSE) Aug 8, 2026
@warelik

warelik commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

中文 TL;DR

M1 按方案 1:标题与正文已收窄 —— 单对话压缩存活;多个对话共享同一 opener 时,压缩后的那一路按歧义规则退化为新 session,这是有意的保守选择,测试早钉住了边界,现在正文也写明了。M2:合并顺序建议本 PR 在 #242 之后,后合要做的已列出。

我实跑过什么

head d4d416f(rebase 到 master 51846e0,本轮无代码改动,只动标题/正文):

结果
npm test 全量 3698 pass / 0 fail
session-continuity-compaction-survival.json 3/3 CAUGHT,anchor 在 rebase 后重核,恰好命中一次

M1 — fork 场景:正文收窄,按方案 1

你的 fork 实测(同一 opener、分叉、压缩)我们认可:共享 opener 的第二个对话压缩后,live.length === 1 那道闸拦住了劫持,但 B 按歧义规则拿到新 session —— 「survive compaction」在 fork 下不成立。标题和正文已收窄:

方案 2(用 dialogAnchor 让歧义可判定)留作后续,不在本 PR —— 同意你的判断:那是给「不可判定的输入」造判据。

M2 — 与 #242 的合并顺序

建议顺序 #242#243#247#248#249,本 PR 在 #242 之后。后合时本 PR 要做的:

  1. rebase 到含 feat(devin-connect): session fidelity for multi-turn agentic work — stable ModelConfig + reasoning continuity (opt-in, default OFF) #242 的 master,重跑本 spec 与 reasoning-continuity.json;
  2. 核你点的那条语义交叉:root 回退命中时 pairWindow 被重索引到压缩后历史的 pair hash 下,而 feat(devin-connect): session fidelity for multi-turn agentic work — stable ModelConfig + reasoning continuity (opt-in, default OFF) #242getSessionModelConfig / getSessionReasoningTrailresolveSessionId 之后调用。分析:这两个 getter 读的是 state 本体(reasoning 队列 / modelConfig),不经 pairIndex,重索引影响的是之后的解析,不是本次读取 —— 预期无冲突,但届时用重跑证明,不用假设。

dwgx added a commit that referenced this pull request Aug 9, 2026
#247 是这批 session-fidelity PR 里唯一默认生效的行为变更(#242 的 MODEL_CONFIG_STABLE /
SESSION_REASONING_INJECT、#243 的 THINKTEXT_REROUTE、#248 复用的 SESSION_REUSE、#249 的
LEAK_TRACE 都默认 OFF),而它的失败形状是内容丢失。线上出现未预见的形状时,操作员需要能在
不回滚版本、不改代码的前提下关掉它。

写法照 identity-neutralize.js:83 的同族先例:只有精确的 '0' 关闭,空串仍然是开 —— 空串
静默关掉一个默认开的安全路径,是 #241/#242 已经吃过两次的形状。

chat.js 四个调用点全部做了 null 安全:feed 走透传分支,noteReasoning 用 ?.,settle 和
release 用 ?? 兜默认值。关闭时输出与「完全没有 dedup」逐字等同。

测试 +5 条(13 -> 18),含行为断言而非源码 grep:驱动 chat.js 实际的两种接线(dedup 实例
vs null),断言到达 wire 的内容。突变验证过 —— 把开关改成恒真,2 条失败。

另记一条给下一个读者:把 || '1' 兜底删掉是等价变换而不是漏网(String('') 已经 !== '0'),
所以那行上的存活突变不是洞;真正会坏的形状是改写成 Number() 或 !env.X,已单独钉住。
@dwgx
dwgx merged commit 2b42fe4 into dwgx:master Aug 9, 2026
5 checks passed
dwgx added a commit that referenced this pull request Aug 9, 2026
…248 往同一批文件加了测试

#242 单独时 289 是对的。合了 #243#248 之后,这个 spec 跑的五个文件里有三个涨了:
session-continuity 46 + devin-connect 174 + messages-incoming-thinking 2 +
devin-connect-openai 75 + commit-fallback 3 = 300(逐文件量的,不是 289+11 推的)。

harness 在 baseline 阶段 die(),11 条突变一条都没跑 —— 而我上一轮的清扫脚本用 $? 取
管道退出码,拿到的是 sed 的 0,把这条漏报成 exit=0。清扫脚本的判据本身有洞,已在下一轮
改成读 verdict 行而不是退出码。
Youzini-afk pushed a commit to Youzini-afk/WindsurfAPI that referenced this pull request Aug 9, 2026
…x#248 dwgx#247

评级对齐 rubric: dwgx#242 双链根因 + 突变钉住(S),dwgx#247 前提识别 + 突变钉住(S),
dwgx#243 干净但范围描述低报(A+),dwgx#248 有实测支撑但只在单对话成立(A)。

随 sync:contributors 同步到 docs/dashboard/data/contributors.json。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants